Skip to content

fix(dashboard): exit edit mode when navigating away (#1037)#1089

Merged
AustinChangLinksys merged 2 commits into
dev-2.6.0from
fix/1037-dashboard-edit-mode-tab-switch
Jul 13, 2026
Merged

fix(dashboard): exit edit mode when navigating away (#1037)#1089
AustinChangLinksys merged 2 commits into
dev-2.6.0from
fix/1037-dashboard-edit-mode-tab-switch

Conversation

@AustinChangLinksys

Copy link
Copy Markdown
Collaborator

Summary

  • Extract edit mode state from view-local to a centralized provider (dashboardEditModeProvider)
  • Add onExit handler to dashboard route that cancels edit mode and reverts unsaved layout changes when user navigates away (e.g., tab switch)
  • Add unit tests for DashboardEditModeNotifier

Test plan

  • Enter edit mode on dashboard
  • Move/resize a widget
  • Switch to Menu or Support tab
  • Return to dashboard — layout should be restored to pre-edit state
  • Verify normal edit flow (save/cancel buttons) still works

Closes #1037

🤖 Generated with Claude Code

Extract edit mode state from view-local to a centralized provider so it
can be accessed by route guards. When user navigates away from dashboard
(e.g., tab switch) during edit mode, onExit cancels edit mode and
reverts any unsaved layout changes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@AustinChangLinksys AustinChangLinksys left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated Review — Round 1 · 93e76ca..c513381 (full)

Verdict: 💬 Self-review (comment only) — Own PR; 0 Criticals; 5 Warnings (re-entrant guard, TOCTOU race, missing error handling, silent discard, naming trap), 2 Suggestions.

Conf. Where Issue (one-liner)
⚠️ 🟢High dashboard_edit_mode_provider.dart:enterEditMode [single, Reviewer A] Re-entrant call overwrites original layout snapshot — lose true pre-edit baseline
⚠️ 🟡Med dashboard_edit_mode_provider.dart:enterEditMode + route_usp_dashboard.dart:onExit [single, Reviewer A] TOCTOU: onExit fires in async gap before isEditing=true; controller stranded in edit mode
⚠️ 🟡Med route_usp_dashboard.dart:onExit [single, Reviewer A] No try-catch; cancelEditMode exception leaves isEditing=true permanently
⚠️ 🟢High route_usp_dashboard.dart:onExit [single, Reviewer B] Silent discard on nav-away diverges from codebase-wide showUnsavedAlert pattern
⚠️ 🟡Med dashboard_edit_mode_provider.dart:exitEditMode [single, Reviewer B] save=false naming is inverted-boolean trap — rename to revert/discardChanges
💡 🟢High dashboard_edit_mode_provider.dart:DashboardEditState [both reviewers] layoutSnapshot: List<dynamic>? weak type; should be List<Map<String, dynamic>>?
💡 🟢High test/page/dashboard/providers/dashboard_edit_mode_provider_test.dart [both reviewers] No integration test covering onExit guard, re-entrant enterEditMode, or TOCTOU race

Confidence: 🟢High = code-verified · 🟡Med = located + reasoned, not fully confirmed · ⚪Low = speculative, please double-check.
Items marked [both reviewers] were independently flagged by two agents → higher confidence.

⚠️ Warning Details

W-1 [single, Reviewer A] · 🟢High — Re-entrant enterEditMode silently overwrites the original snapshot

lib/page/dashboard/providers/dashboard_edit_mode_provider.dartenterEditMode():

Future<void> enterEditMode() async {
  await ref.read(uspLayoutPreferencesProvider.notifier).initialized;
  final controller = ref.read(uspSliverDashboardControllerProvider);
  final layoutSnapshot = controller.exportLayout();  // ← re-captures already-modified grid
  state = DashboardEditState(
    isEditing: true,
    layoutSnapshot: layoutSnapshot,  // ← unconditionally overwrites original baseline
    ...
  );
}

No if (state.isEditing) return guard. A double-tap or gesture race calls enterEditMode() while already editing, re-capturing the current (already-modified) layout as the new "original" snapshot. Tapping Cancel or navigating away then reverts to the mid-edit state, not the true pre-edit baseline.

Fix: if (state.isEditing) return; as the first line.


W-2 [single, Reviewer A] · 🟡Med — TOCTOU: onExit fires in async gap before isEditing=true

enterEditMode() sets isEditing=true only after await initialized. If the user taps Edit then immediately navigates away before initialized resolves, onExit reads isEditing=false and skips cancelEditMode(). enterEditMode then resumes and sets isEditing=true — the controller is permanently stuck in edit mode on a different page.

Fix: Set state = state.copyWith(isEditing: true) before the await to claim the slot early; onExit will then always see the correct state.


W-3 [single, Reviewer A] · 🟡Med — No try-catch in onExit; exception leaves stale isEditing=true

lib/route/route_usp_dashboard.dart:onExit:

onExit: (context, state) async {
  if (editState.isEditing) {
    await container.read(dashboardEditModeProvider.notifier).cancelEditMode(); // unguarded
  }
  return true;
}

cancelEditMode() calls saveLayout() and restoreSnapshot(), both of which can throw (SharedPreferences failure, platform exception). An unhandled exception leaves dashboardEditModeProvider.isEditing=true permanently; the next dashboard visit has stale state.

Fix: Wrap in try-catch and force-reset provider state on error.


W-4 [single, Reviewer B] · 🟢High — Silent discard diverges from showUnsavedAlert pattern

Every other editable route in the codebase (uspInstantSafety, uspDhcpDetail, uspWifiSettings, etc.) uses enableDirtyCheck: true / showUnsavedAlert before discarding unsaved changes. The dashboard onExit silently calls cancelEditMode() without prompting. A user who navigated away accidentally after rearranging the dashboard loses all work without warning.

Fix: Either use showUnsavedAlert(context) before cancelEditMode(), or add an explicit code comment documenting the intentional silent-discard policy.


W-5 [single, Reviewer B] · 🟡Med — save=false naming is inverted-boolean trap

exitEditMode({bool save = true}) — the save=false branch calls controller.importLayout(snapshot) and then still calls saveLayout() (persists the reverted snapshot to disk). The parameter name says "don't save" but a save operation executes inside the !save branch.

Fix: Rename to exitEditMode({bool revert = false}), or expose only cancelEditMode() / commitEditMode() as the public API and make exitEditMode private.

✅ What looks good
  • Full migration confirmed: Synthesizer re-read the complete diff — all 7 _isEditMode references in usp_sliver_dashboard_view.dart (_buildHeader, _buildSliverDashboard ×2, trashBuilder ×2, _buildItemWidget ×2, _openLayoutSettings) are updated to use ref.watch(dashboardEditModeProvider).isEditing. No stale local-state references remain.
  • Architecture compliance: Three-layer rules respected. Provider imports only models and sibling providers; view imports only the new provider. No codegen model imports. No custom widgets introduced.
  • Provider rules: NotifierProvider (non-autoDispose, correct L1). All ref.read inside notifier methods (correct L2 pattern). No uspMutationLockProvider needed (local preference writes, not USP API mutations). PreservableContract not redefined.
  • Route guard idiom: ProviderScope.containerOf(context) is the idiomatic pattern for accessing Riverpod providers in go_router callbacks outside widget tree.
  • Unit test coverage: dashboard_edit_mode_provider_test.dart (172 lines) covers state model, enter, exit (save/revert), cancel, and multi-cycle stability. Solid provider-level unit coverage.
  • No security issues: No hardcoded secrets, injection surfaces, permission bypasses, or unsafe data handling.

Cross-reviewed by two independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.

@AustinChangLinksys AustinChangLinksys added the need-decide Automated review left findings needing Austin to decide (fix/defer/dismiss). label Jul 7, 2026

@PeterJhongLinksys PeterJhongLinksys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_openLayoutSettings reverts the reset/preset change it's meant to keep

In usp_sliver_dashboard_view.dart, when the layout-settings dialog returns 'reset' / 'toggle_off' / 'preset_changed', the handler now calls exitEditMode(save: false). The inline comment says "the settings panel already applied the reset/preset change directly" — but save: false does the opposite of preserving it.

In dashboard_edit_mode_provider.dart, the save == false branch runs controller.importLayout(state.layoutSnapshot!) + saveLayout() and restoreSnapshot(state.prefsSnapshot!), i.e. it reverts to the pre-edit snapshot captured in enterEditMode. Since resetToDefaults() / selectPreset() have already mutated the controller/prefs to the new state, this call undoes the user's just-applied reset / preset-change / toggle-off. The snapshots are always populated (enterEditMode sets them unconditionally) and the settings button is only reachable in edit mode, so the revert always fires.

The previous code intentionally only cleared the edit flag and nulled the snapshots to keep the applied change. Suggest exiting edit mode here without the revert path (e.g. a save/commit exit, or clear-state-only), so the reset/preset the user selected is preserved.

(Distinct from the earlier W-5 note, which framed save's naming in the provider rather than this call-site's functional effect.)

)

Address PR #1089 review. Root cause: exitEditMode({bool save}) was an
inverted-boolean trap — the !save branch persisted the pre-edit snapshot,
so the settings-panel reset/preset path (which had already applied its
change) called save:false and silently reverted the user's change.

- Replace exitEditMode({bool save}) with explicit commitEditMode()
  (keep changes) and cancelEditMode() (revert to snapshot), sharing a
  private _exitEditMode({required bool revert}) that always resets state
  in a finally block (no stranded isEditing=true on save/restore failure)
- Route _openLayoutSettings reset/preset path to commitEditMode() so the
  just-applied change is preserved; drop dead 'toggle_off' branch
- enterEditMode: add re-entrant guard and claim isEditing before the
  await so an onExit firing in the async gap can never strand edit mode
- route_usp_dashboard onExit: wrap cancelEditMode in try-catch and
  document the intentional silent-discard policy
- Tighten DashboardEditState.layoutSnapshot to List<Map<String, dynamic>>?
- Delete unused lib/route/linksys_route.dart (dead code; active
  LinksysRoute lives in route_model.dart)
- Add regression tests: commit-preserves-changes, re-entrant guard (W-1),
  cancel-during-async-gap (W-2)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@AustinChangLinksys AustinChangLinksys left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated Review — Round 2 · c513381..963db6d (incremental)

Verdict: 💬 Self-review (comment only) — GitHub prohibits self-approval; review posted as comment. 0 Critical; 2 Warnings (partial-revert inconsistency on exception, void-async exception swallowing); 5 Suggestions. All Round-1 warnings confirmed resolved.

Conf. Where Issue (one-liner)
⚠️ 🟢High dashboard_edit_mode_provider.dart:82–101 [single, Reviewer A] saveLayout() exception inside _exitEditMode(revert:true) skips restoreSnapshot() → controller memory and prefs left in inconsistent state
⚠️ 🟢High usp_sliver_dashboard_view.dart:115–121 [single, Reviewer B] _commitEditMode / _cancelEditMode are void async — exceptions silently discarded, no error log, no observability
💡 🟢High dashboard_edit_mode_provider.dart:26–38 [single, Reviewer A] copyWith() defined but never called inside the Notifier; only used in tests — risk of future misuse
💡 🟢High dashboard_edit_mode_provider.dart:49–73 [single, Reviewer B] Snapshot-null window (isEditing=true but layoutSnapshot=null) between claim and full population is undocumented
💡 🟢High dashboard_edit_mode_provider.dart:89 [single, Reviewer B] _exitEditMode lacks idempotency guard — callable when isEditing=false (harmless today, but asymmetric with enterEditMode)
💡 🟢High usp_sliver_dashboard_view.dart:511 [single, Reviewer B] toggle_off removal from _openLayoutSettings result handler lacks an explanatory comment (dead code since previous iteration)
💡 🟢High lib/route/linksys_route.dart (deleted) [single, Reviewer B] Deletion of dead code is correct; PR description could document the FeatureState.isDirtyPreservableContract evolution for future reference

Confidence: 🟢High = code-verified · 🟡Med = located + reasoned, not fully confirmed · ⚪Low = speculative, please double-check.
Items marked [both reviewers] were independently flagged by two agents → higher confidence.

✅ Round-1 Resolutions Confirmed

All 7 Round-1 findings are fully resolved in this diff:

  • W-1 Re-entrant enterEditMode — ✅ if (state.isEditing) return; guard added at dashboard_edit_mode_provider.dart:50.
  • W-2 TOCTOU race — ✅ state = const DashboardEditState(isEditing: true) set before the await (line 54); post-await if (!state.isEditing) return; bail-out added (line 60).
  • W-3 No try-catch in onExit — ✅ try/catch wraps cancelEditMode() in route_usp_dashboard.dart with logger.e on failure.
  • W-4 Silent-discard vs. showUnsavedAlert — ✅ Intentional-policy comment added in route_usp_dashboard.dart:21–29.
  • W-5 save=false inverted-boolean naming — ✅ API renamed to commitEditMode() / cancelEditMode() / _exitEditMode({required bool revert}).
  • S-1 List<dynamic>? weak type — ✅ Upgraded to List<Map<String, dynamic>>?.
  • S-2 Missing integration tests — ✅ New tests added: commitEditMode-preserves-changes, W-1 re-entrant regression, W-2 TOCTOU cancel-during-async-gap.
  • PeterJhong comment (_openLayoutSettings reset/preset reverts change) — ✅ _openLayoutSettings now calls commitEditMode() for reset/preset_changed (keeps the applied change instead of reverting); toggle_off branch removed (was dead code — settings panel never emitted this string).
  • linksys_route.dart deletion — ✅ Confirmed safe: file was never imported anywhere; active LinksysRoute lives in route_model.dart using PreservableContract.
⚠️ Warning Details

W-1 [single, Reviewer A] · 🟢High — Partial revert on saveLayout() exception leaves layout/prefs inconsistent

lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:82–101

Future<void> _exitEditMode({required bool revert}) async {
  final controller = ref.read(uspSliverDashboardControllerProvider);
  try {
    if (revert) {
      if (state.layoutSnapshot != null) {
        controller.importLayout(state.layoutSnapshot!);   // Step 1: in-memory revert OK
        await ref
            .read(uspSliverDashboardControllerProvider.notifier)
            .saveLayout();                                // Step 2: can throw
      }
      if (state.prefsSnapshot != null) {
        await ref
            .read(uspLayoutPreferencesProvider.notifier)
            .restoreSnapshot(state.prefsSnapshot!);      // Step 3: skipped on Step 2 throw
      }
    }
  } finally {
    controller.setEditMode(false);
    state = const DashboardEditState();                  // snapshots cleared, no retry possible
  }
}

If saveLayout() throws (SharedPreferences I/O failure), execution jumps to finally, skipping restoreSnapshot(). Resulting state:

Layer State after exception
Controller (in-memory) Pre-edit layout (visually reverted)
Persistent storage Last autosave during edit (NOT reverted)
Prefs (memory + storage) Post-edit state (NOT reverted)

The user sees the layout reverted but prefs remain in the edited state. On app restart the combination can produce inconsistent UI. The finally comment only guarantees "edit mode won't be stranded" — it does not protect layout/prefs consistency.

Trigger: Any I/O error in saveLayout() during a cancel/nav-away (uncommon, not impossible).

Fix:

try {
  if (revert) {
    if (state.layoutSnapshot != null) {
      controller.importLayout(state.layoutSnapshot!);
      try {
        await ref.read(uspSliverDashboardControllerProvider.notifier).saveLayout();
      } catch (e, s) {
        logger.e('[Dashboard] saveLayout failed during revert; continuing to restore prefs',
            error: e, stackTrace: s);
        // continue — restoreSnapshot must still run to keep prefs consistent
      }
    }
    if (state.prefsSnapshot != null) {
      await ref.read(uspLayoutPreferencesProvider.notifier).restoreSnapshot(state.prefsSnapshot!);
    }
  }
} finally {
  controller.setEditMode(false);
  state = const DashboardEditState();
}

W-2 [single, Reviewer B] · 🟢High — void async in view-layer wrappers silently discards exceptions

lib/page/dashboard/views/usp_sliver_dashboard_view.dart:115–121

void _commitEditMode() async {
  await ref.read(dashboardEditModeProvider.notifier).commitEditMode();
}

void _cancelEditMode() async {
  await ref.read(dashboardEditModeProvider.notifier).cancelEditMode();
}

In Dart, void async functions discard the Future; any uncaught exception surfaces only through FlutterError.onError or the zone's uncaught-error handler. In production, errors in commitEditMode() / cancelEditMode() become invisible. The _exitEditMode finally block guarantees state is reset, so no functional regression — but failure observability is lost.

This pattern (_enterEditMode already uses it) is an existing convention in the view; the new methods extend it. Not blocking, but an opportunity to improve diagnostics.

Fix (non-breaking):

void _commitEditMode() {
  ref.read(dashboardEditModeProvider.notifier).commitEditMode()
    .catchError((e, s) => logger.e('[Dashboard] commitEditMode failed', error: e, stackTrace: s));
}

void _cancelEditMode() {
  ref.read(dashboardEditModeProvider.notifier).cancelEditMode()
    .catchError((e, s) => logger.e('[Dashboard] cancelEditMode failed', error: e, stackTrace: s));
}
✅ What looks good
  • Core logic is correct end-to-end — re-entrant guard, TOCTOU fix, API rename, _openLayoutSettings commit path, toggle_off dead-code removal all confirmed correct by reading actual source.
  • Architecture complianceNotifierProvider (non-autoDispose), all ref.read inside notifier, no USP mutation lock needed (pure local-prefs writes), no codegen imports, no custom widgets. Fully compliant with three-layer and provider rules.
  • finally block guarantees no stuck edit-mode — even if revert operations partially fail, the controller and provider state are always reset. The main gap (W-1) is about data consistency, not edit-mode being stranded.
  • Test suite is comprehensive at the provider level — re-entrant, TOCTOU, commit-preserves-changes, multi-cycle, cancel-revert all covered. Strong regression prevention.
  • linksys_route.dart deletion confirmed saferoute_model.dart is the sole active LinksysRoute definition; the deleted file was never imported; no duplicate-class risk.
  • No security issues — no hardcoded secrets, no injection surfaces, no permission bypasses, no JNAP/USP response handling changes.

Cross-reviewed by two independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.

@AustinChangLinksys AustinChangLinksys removed the need-decide Automated review left findings needing Austin to decide (fix/defer/dismiss). label Jul 10, 2026

@AustinChangLinksys AustinChangLinksys left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated Review — Round 3 · c513381..963db6d (incremental)

Verdict: 💬 Self-review (comment only) — GitHub prohibits self-approval. PeterJhong's _openLayoutSettings bug confirmed resolved in this diff. Two carry-over Warnings (W-1 data-inconsistency, W-2 void-async observability) and one new Warning (N-1 async-gap no-op) remain open.

Conf. Where Issue (one-liner)
⚠️ 🟢High dashboard_edit_mode_provider.dart:82–101 [single, Reviewer A, CARRY-OVER W-1] saveLayout() exception skips restoreSnapshot() — prefs not reverted on I/O failure
⚠️ 🟢High usp_sliver_dashboard_view.dart:115–121 [both reviewers, CARRY-OVER W-2] _commitEditMode / _cancelEditMode are void async — exceptions silently discarded
⚠️ 🟡Med dashboard_edit_mode_provider.dart:47–72 [single, Reviewer A] cancelEditMode during enterEditMode's async gap is a silent no-op (snapshots null — upgrade from prior S-2)
💡 🟢High dashboard_edit_mode_provider.dart:89 [both reviewers] _exitEditMode lacks idempotency guard symmetric with enterEditMode's re-entrant check
💡 🟡Med usp_sliver_dashboard_view.dart:401 [single, Reviewer B] _buildTrashZone uses bare Container/BoxDecoration instead of ui_kit_library — pre-existing tech debt
💡 ⚪Low usp_sliver_dashboard_view.dart:514 [single, Reviewer B] toggle_off silently unhandled if re-added to settings panel in future
💡 🟢High test/.../dashboard_edit_mode_provider_test.dart [single, Reviewer B] No test for cancelEditMode/commitEditMode invoked when isEditing==false (idempotency)

Confidence: 🟢High = code-verified · 🟡Med = located + reasoned, not fully confirmed · ⚪Low = speculative, please double-check.
Items marked [both reviewers] were independently flagged by two agents → higher confidence.

⚠️ Warning Details

W-1 [single, Reviewer A, CARRY-OVER] · 🟢High — saveLayout() exception skips restoreSnapshot()

lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:82–101

Future<void> _exitEditMode({required bool revert}) async {
  final controller = ref.read(uspSliverDashboardControllerProvider);
  try {
    if (revert) {
      if (state.layoutSnapshot != null) {
        controller.importLayout(state.layoutSnapshot!);   // Step 1: in-memory revert OK
        await ref
            .read(uspSliverDashboardControllerProvider.notifier)
            .saveLayout();                                // Step 2: can throw
      }
      if (state.prefsSnapshot != null) {
        await ref
            .read(uspLayoutPreferencesProvider.notifier)
            .restoreSnapshot(state.prefsSnapshot!);       // Step 3: SKIPPED on Step 2 throw
      }
    }
  } finally {
    controller.setEditMode(false);
    state = const DashboardEditState();
  }
}

The try/finally added in this commit fixes "edit mode won't be stranded" but does NOT fix the data-consistency gap. If saveLayout() throws:

Layer State after exception
In-memory layout Reverted (importLayout ran)
Persistent layout Not reverted (saveLayout failed)
In-memory prefs Not reverted (restoreSnapshot skipped)
UI state Cleared (finally block)

User sees layout visually reverted but prefs remain in post-edit state; on app restart the combination produces inconsistent UI. Trigger: SharedPreferences I/O failure during cancelEditMode (uncommon, not impossible).

Fix: Wrap saveLayout() in its own inner try-catch so restoreSnapshot() always runs:

if (state.layoutSnapshot != null) {
  controller.importLayout(state.layoutSnapshot!);
  try {
    await ref.read(uspSliverDashboardControllerProvider.notifier).saveLayout();
  } catch (e, s) {
    logger.e('[Dashboard] saveLayout failed during revert; continuing to restoreSnapshot',
        error: e, stackTrace: s);
  }
}
if (state.prefsSnapshot != null) {
  await ref.read(uspLayoutPreferencesProvider.notifier)
      .restoreSnapshot(state.prefsSnapshot!);
}

W-2 [both reviewers, CARRY-OVER] · 🟢High — void async view-layer wrappers silently discard exceptions

lib/page/dashboard/views/usp_sliver_dashboard_view.dart:115–121

void _commitEditMode() async {
  await ref.read(dashboardEditModeProvider.notifier).commitEditMode();
}

void _cancelEditMode() async {
  await ref.read(dashboardEditModeProvider.notifier).cancelEditMode();
}

Both remain void async with no try-catch. Exceptions from the notifier are silently swallowed. Compare: route_usp_dashboard.dart's onExit already wraps cancelEditMode() in try-catch with logger.e. The UI-button call sites (✓ commit, ✗ cancel buttons) lack equivalent protection.

Fix (non-breaking):

void _commitEditMode() async {
  try {
    await ref.read(dashboardEditModeProvider.notifier).commitEditMode();
  } catch (e, s) {
    logger.e('[Dashboard] commitEditMode failed', error: e, stackTrace: s);
  }
}
void _cancelEditMode() async {
  try {
    await ref.read(dashboardEditModeProvider.notifier).cancelEditMode();
  } catch (e, s) {
    logger.e('[Dashboard] cancelEditMode failed', error: e, stackTrace: s);
  }
}

N-1 [single, Reviewer A] · 🟡Med — cancelEditMode during enterEditMode's async gap is a silent no-op

lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:47–72

Future<void> enterEditMode() async {
  if (state.isEditing) return;
  state = const DashboardEditState(isEditing: true);  // snapshots still null here

  await ref.read(uspLayoutPreferencesProvider.notifier).initialized;  // async gap

  // If cancelEditMode() fires HERE: snapshots are null, _exitEditMode(revert:true)
  // skips both importLayout and restoreSnapshot — effectively a no-op.
  // This is SAFE because controller.setEditMode(true) hasn't been called yet,
  // but the invariant is undocumented.

  if (!state.isEditing) return;  // bail-out fires correctly, no stuck state
  // ... capture snapshots, setEditMode(true)
}

The if (!state.isEditing) return guard correctly prevents stuck state. The silent no-op is functionally safe (controller not yet in edit mode, no data to revert). Risk is low in practice (window is as long as initialized is pending, typically near-zero on warm start). The TOCTOU test does not exercise a genuinely-pending initialized.

Suggest: Add code comment explaining the null-snapshot invariant so future maintainers understand the silent no-op is intentional.

✅ What looks good (including PeterJhong resolution)
  • PeterJhong's _openLayoutSettings fix confirmedreset/preset_changed paths now correctly call commitEditMode(), preserving the user's just-applied change. toggle_off dead-code removed correctly (settings panel never emits this string at HEAD — verified).
  • Re-entrant guard and TOCTOU fix confirmedif (state.isEditing) return; + pre-await state claim + post-await bail-out are correct, symmetric, and covered by regression tests.
  • finally block guarantees no stuck edit-mode — even if saveLayout() and restoreSnapshot() both fail, setEditMode(false) and state = const DashboardEditState() always execute.
  • Route guard error handlingcancelEditMode() in route_usp_dashboard.dart:onExit correctly wrapped in try-catch; navigation never blocked.
  • Test suite additionscommitEditMode preserves layout changes (PeterJhong regression), re-entrant guard, TOCTOU cancel-during-async-gap, multi-cycle tests comprehensively cover the corrected behavior.
  • Architecture complianceNotifierProvider (non-autoDispose), all ref.read inside notifier, correct 3-layer boundaries maintained throughout.
  • No security issues — no hardcoded secrets, no injection vectors, no permission changes, no JNAP/USP response handling modifications.

Cross-reviewed by two independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.

@AustinChangLinksys AustinChangLinksys left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated Review — Round 4 · c513381..963db6d (incremental)

Verdict: 💬 Self-review (comment only) — GitHub prohibits self-approval. Good progress this round: PeterJhong's _openLayoutSettings reset/preset fix confirmed, re-entrant guard and async-gap cancel correctly handled and tested, toggle_off dead code removed, stale linksys_route.dart deleted. Two carry-over Warnings and two new Warnings remain open.

Conf. Where Issue (one-liner)
⚠️ 🟢High dashboard_edit_mode_provider.dart:94–103 [CARRY-OVER W-1, single] saveLayout() exception skips restoreSnapshot() — prefs not reverted on I/O failure, grid and prefs left inconsistent
⚠️ 🟢High usp_sliver_dashboard_view.dart:115–119 [CARRY-OVER W-2, both reviewers] _commitEditMode/_cancelEditMode remain void async — exceptions from button-tap path silently discarded
⚠️ 🟢High dashboard_edit_mode_provider.dart:89 [NEW, both reviewers] _exitEditMode lacks isEditing guard — unnecessary controller updates on redundant calls; concurrent cancel+commit race risk
⚠️ 🟢High test/.../dashboard_edit_mode_provider_test.dart [NEW, single, Reviewer B] Missing isEditing==false idempotency tests for commitEditMode/cancelEditMode
💡 🟡Med usp_sliver_dashboard_view.dart:514–519 [single, Reviewer B] _openLayoutSettings result handler lacks exhaustive comment — silent skip if toggle_off is re-added
💡 🟡Med dashboard_edit_mode_provider.dart:106–108 [single, Reviewer B] finally captures controller ref before awaits — stale if controller replaced during revert
💡 ⚪Low dashboard_edit_mode_provider.dart:17 [single] layoutSnapshot narrowed to List<Map<String,dynamic>> but usp_layout_controller.dart still casts to List<dynamic> — partial type alignment

Confidence: 🟢High = code-verified · 🟡Med = located + reasoned, not fully confirmed · ⚪Low = speculative, please double-check.
Items marked [both reviewers] were independently flagged by two agents → higher confidence.

⚠️ Warning Details

W-1 [CARRY-OVER, single, Reviewer A] · 🟢High — saveLayout() exception skips restoreSnapshot()

lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:89–109

Future<void> _exitEditMode({required bool revert}) async {
  final controller = ref.read(uspSliverDashboardControllerProvider);

  try {
    if (revert) {
      if (state.layoutSnapshot != null) {
        controller.importLayout(state.layoutSnapshot!);   // Step 1: in-memory revert
        await ref
            .read(uspSliverDashboardControllerProvider.notifier)
            .saveLayout();                                // Step 2: can throw
      }
      if (state.prefsSnapshot != null) {
        await ref
            .read(uspLayoutPreferencesProvider.notifier)
            .restoreSnapshot(state.prefsSnapshot!);       // Step 3: SKIPPED if Step 2 throws
      }
    }
  } finally {
    controller.setEditMode(false);
    state = const DashboardEditState();                   // stuck-mode fixed — consistency gap remains
  }
}

The Round 3 try/finally correctly fixed stuck edit-mode. But the data-consistency gap is unchanged: if saveLayout() throws, control jumps to finally, skipping restoreSnapshot(). After failure:

Layer State
In-memory grid Reverted (importLayout ran)
Persisted layout Not reverted (saveLayout failed)
In-memory prefs Not reverted (restoreSnapshot skipped)
UI state Cleared (finally)

User sees grid visually reverted but prefs remain mid-edit; on restart the combination produces inconsistent UI. Trigger: SharedPreferences I/O failure during cancelEditMode.

Fix: Wrap saveLayout() in its own inner try-catch so restoreSnapshot() always executes:

if (state.layoutSnapshot != null) {
  controller.importLayout(state.layoutSnapshot!);
  try {
    await ref.read(uspSliverDashboardControllerProvider.notifier).saveLayout();
  } catch (e, s) {
    logger.e('[Dashboard] saveLayout failed during revert; continuing to restoreSnapshot',
        error: e, stackTrace: s);
  }
}
if (state.prefsSnapshot != null) {
  await ref.read(uspLayoutPreferencesProvider.notifier)
      .restoreSnapshot(state.prefsSnapshot!);
}

W-2 [CARRY-OVER, both reviewers] · 🟢High — void async view wrappers silently discard exceptions from button taps

lib/page/dashboard/views/usp_sliver_dashboard_view.dart:115–119

void _commitEditMode() async {
  await ref.read(dashboardEditModeProvider.notifier).commitEditMode();
}

void _cancelEditMode() async {
  await ref.read(dashboardEditModeProvider.notifier).cancelEditMode();
}

Both remain void async with no try-catch. The route_usp_dashboard.dart:onExit path now has a try-catch (good), but the check/close button paths (onTap: _commitEditMode, onTap: _cancelEditMode) do not. If saveLayout() throws from a button tap, the user sees no feedback and no error is logged from the view layer.

Fix:

void _commitEditMode() async {
  try {
    await ref.read(dashboardEditModeProvider.notifier).commitEditMode();
  } catch (e, s) {
    logger.e('[Dashboard] commitEditMode failed', error: e, stackTrace: s);
  }
}
void _cancelEditMode() async {
  try {
    await ref.read(dashboardEditModeProvider.notifier).cancelEditMode();
  } catch (e, s) {
    logger.e('[Dashboard] cancelEditMode failed', error: e, stackTrace: s);
  }
}

W-3 [NEW, both reviewers] · 🟢High — _exitEditMode lacks isEditing idempotency guard

lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:89

enterEditMode() has if (state.isEditing) return; but _exitEditMode has no symmetric guard. If both _cancelEditMode (button) and onExit (route) fire concurrently in an async gap:

  1. First call starts _exitEditMode(revert: true), importLayout runs, saveLayout() awaiting.
  2. Second call enters _exitEditMode(revert: true)state.layoutSnapshot is null (first call's finally already cleared state), so importLayout/restoreSnapshot skipped, but controller.setEditMode(false) and state = const DashboardEditState() fire again — triggering unnecessary Riverpod listener notifications.

While the finally guarantees correct end state, the duplicate controller/state updates are observable side-effects.

Fix:

Future<void> _exitEditMode({required bool revert}) async {
  if (!state.isEditing) return;  // symmetric guard matching enterEditMode
  final controller = ref.read(uspSliverDashboardControllerProvider);
  // ... rest unchanged
}

W-4 [NEW, single, Reviewer B] · 🟢High — Missing isEditing==false idempotency unit tests

test/page/dashboard/providers/dashboard_edit_mode_provider_test.dart

This round added four valuable tests (re-entrant guard, async-gap cancel, PeterJhong regression, multi-cycle). However, none directly tests calling commitEditMode() or cancelEditMode() when isEditing == false. If W-3's guard is added, this path becomes a documented early-return; without a test, future refactors could break it silently.

Fix: Add:

test('commitEditMode when not editing is a safe no-op', () async {
  final container = await createContainer();
  await container.read(dashboardEditModeProvider.notifier).commitEditMode();
  expect(container.read(dashboardEditModeProvider).isEditing, isFalse);
});

test('cancelEditMode when not editing is a safe no-op', () async {
  final container = await createContainer();
  await container.read(dashboardEditModeProvider.notifier).cancelEditMode();
  expect(container.read(dashboardEditModeProvider).isEditing, isFalse);
});
✅ What looks good (including this round's resolutions)
  • PeterJhong's _openLayoutSettings fix confirmedreset/preset_changed now correctly call commitEditMode() (keeps the applied change). toggle_off dead code removed cleanly.
  • Re-entrant enterEditMode guardif (state.isEditing) return; prevents snapshot overwrite on double-tap; state claimed before await ensures onExit always observes edit state.
  • Async-gap cancel coveredif (!state.isEditing) return; post-await bail-out correct, now has a dedicated test.
  • finally block guarantees no stuck edit-modesetEditMode(false) and state = DashboardEditState() always execute regardless of I/O failures.
  • linksys_route.dart deleted — Duplicate LinksysRoute subclass using deprecated FeatureState.isDirty API removed; route_model.dart is now the single definition.
  • route_usp_dashboard.dart onExit hardeningcancelEditMode() wrapped in try-catch with logger.e; navigation never blocked on revert failure.
  • layoutSnapshot type narrowedList<dynamic>? to List<Map<String,dynamic>>? correctly reflects DashboardController.exportLayout() return type.
  • API rename (commit/cancel) — Clearer semantics than save: bool parameter; all call sites updated.
  • Architecture complianceNotifierProvider (non-autoDispose), all ref.read inside notifier, 3-layer boundaries maintained.
  • Test suite additions — Re-entrant guard, TOCTOU cancel-during-gap, PeterJhong regression, multi-cycle tests comprehensively cover the corrected behavior.
  • No security issues — No hardcoded secrets, no injection vectors, no JNAP/USP response handling modifications.

Cross-reviewed by two independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.

@AustinChangLinksys AustinChangLinksys left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated Review — Round 2 · c513381..963db6d (incremental)

Verdict: 💬 Self-review (comment only) — GitHub prohibits self-approval. PeterJhong's _openLayoutSettings commit/revert regression correctly fixed this round. Four carry-over Warnings remain open; one new Warning added.

Conf. Where Issue (one-liner)
⚠️ High dashboard_edit_mode_provider.dart:94-103 [both reviewers, CARRY-OVER W-1] saveLayout() exception skips restoreSnapshot() — prefs not reverted on I/O failure, grid/prefs left inconsistent
⚠️ High usp_sliver_dashboard_view.dart:115-122 [both reviewers, CARRY-OVER W-2] _commitEditMode/_cancelEditMode remain void async — exceptions from button-tap path silently discarded
⚠️ High dashboard_edit_mode_provider.dart:89 [both reviewers, CARRY-OVER W-3] _exitEditMode lacks isEditing guard — concurrent cancel+commit race; redundant controller updates
⚠️ Med test/.../dashboard_edit_mode_provider_test.dart [both reviewers, CARRY-OVER W-4] Missing isEditing==false idempotency tests for commitEditMode/cancelEditMode
⚠️ High usp_sliver_dashboard_view.dart:514 [both reviewers, NEW W-5] toggle_off result silently dropped — no comment explaining removal; future contributors may re-add it without realising it's now unhandled

Confidence: High = code-verified · Med = located + reasoned, not fully confirmed · Low = speculative, please double-check.
Items marked [both reviewers] were independently flagged by two agents → higher confidence.

Warning Details

W-1 [both reviewers, CARRY-OVER] · High — saveLayout() exception skips restoreSnapshot()

lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:94-103

try {
  if (revert) {
    if (state.layoutSnapshot != null) {
      controller.importLayout(state.layoutSnapshot!);
      await ref
          .read(uspSliverDashboardControllerProvider.notifier)
          .saveLayout();  // can throw; restoreSnapshot SKIPPED if this throws
    }
    if (state.prefsSnapshot != null) {
      await ref
          .read(uspLayoutPreferencesProvider.notifier)
          .restoreSnapshot(state.prefsSnapshot!);  // never reached on saveLayout throw
    }
  }
} finally {
  controller.setEditMode(false);
  state = const DashboardEditState();  // snapshots wiped; no retry possible
}

When saveLayout() throws (SharedPreferences I/O failure), finally fires clearing the edit flag and snapshots, but restoreSnapshot never ran. Post-failure: grid in-memory = reverted, grid on-disk = still edited, prefs = still edited. Three sources of truth are inconsistent and unrecoverable.

Fix: Wrap each revert step independently:

if (state.layoutSnapshot != null) {
  controller.importLayout(state.layoutSnapshot!);
  try {
    await ref.read(uspSliverDashboardControllerProvider.notifier).saveLayout();
  } catch (e, s) {
    logger.e('[Dashboard] saveLayout failed during revert — continuing to restoreSnapshot',
        error: e, stackTrace: s);
  }
}
if (state.prefsSnapshot != null) {
  await ref.read(uspLayoutPreferencesProvider.notifier)
      .restoreSnapshot(state.prefsSnapshot!);
}

W-2 [both reviewers, CARRY-OVER] · High — void async view wrappers silently discard exceptions

lib/page/dashboard/views/usp_sliver_dashboard_view.dart:115-122

void _commitEditMode() async {
  await ref.read(dashboardEditModeProvider.notifier).commitEditMode();
}

void _cancelEditMode() async {
  await ref.read(dashboardEditModeProvider.notifier).cancelEditMode();
}

Both remain void async with no try-catch. Any exception propagates into an unhandled Future and is silently swallowed. The route onExit path was correctly wrapped in try-catch this round; the button-tap paths were not.

Fix:

void _commitEditMode() async {
  try {
    await ref.read(dashboardEditModeProvider.notifier).commitEditMode();
  } catch (e, s) {
    logger.e('[Dashboard]: commitEditMode failed', error: e, stackTrace: s);
  }
}
void _cancelEditMode() async {
  try {
    await ref.read(dashboardEditModeProvider.notifier).cancelEditMode();
  } catch (e, s) {
    logger.e('[Dashboard]: cancelEditMode failed', error: e, stackTrace: s);
  }
}

W-3 [both reviewers, CARRY-OVER] · High — _exitEditMode lacks isEditing guard

lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:89

enterEditMode() has if (state.isEditing) return; but _exitEditMode has no symmetric guard. Concurrent path: user taps commit then cancel before saveLayout() returns. Since state.isEditing is still true during the await, the second call enters _exitEditMode(revert: true) and starts a full revert in parallel. Both race to call controller.setEditMode(false) and state = const DashboardEditState(). Outcome: commit followed by an unintended revert. The route onExit is guarded (if (editState.isEditing)); the provider API is not.

Fix:

Future<void> _exitEditMode({required bool revert}) async {
  if (!state.isEditing) return;  // symmetric guard matching enterEditMode
  final controller = ref.read(uspSliverDashboardControllerProvider);
  // ... rest unchanged
}

W-4 [both reviewers, CARRY-OVER] · Med — Missing isEditing==false idempotency tests

test/page/dashboard/providers/dashboard_edit_mode_provider_test.dart

New tests cover re-entrant guard, async-gap cancel, PeterJhong regression, multi-cycle. None test calling commitEditMode() or cancelEditMode() from an isEditing == false baseline.

Fix:

test('commitEditMode when not editing is a safe no-op', () async {
  final container = await createContainer();
  await container.read(dashboardEditModeProvider.notifier).commitEditMode();
  expect(container.read(dashboardEditModeProvider).isEditing, isFalse);
});
test('cancelEditMode when not editing is a safe no-op', () async {
  final container = await createContainer();
  await container.read(dashboardEditModeProvider.notifier).cancelEditMode();
  expect(container.read(dashboardEditModeProvider).isEditing, isFalse);
});

W-5 [both reviewers, NEW] · High — toggle_off result silently dropped without documentation

lib/page/dashboard/views/usp_sliver_dashboard_view.dart:514

- if ((result == 'reset' || result == 'toggle_off' || result == 'preset_changed') && mounted) {
+ if ((result == 'reset' || result == 'preset_changed') && mounted) {

Both reviewers verified that usp_layout_settings_panel.dart currently does not emit toggle_off — so the removal is functionally correct today. However, project documentation lists toggle_off as a valid dialog result. With no comment explaining the removal, a future contributor who re-adds Navigator.pop(context, 'toggle_off') will get a silent no-op: no commit, no cancel — edit mode stays active after the dialog closes.

Fix:

// Note: 'toggle_off' intentionally removed — settings panel no longer emits this
// result as of this PR. If re-added to the panel, update this handler accordingly.
if ((result == 'reset' || result == 'preset_changed') && mounted) {
What looks good (including this round's resolutions)
  • PeterJhong's _openLayoutSettings regression RESOLVEDreset and preset_changed now correctly call commitEditMode() (preserve the applied change). Prior exitEditMode(save: false) that inadvertently reverted the user's just-applied change is gone. Regression test added.
  • Re-entrant enterEditMode guard confirmedif (state.isEditing) return; and pre-await state claim correctly prevent snapshot overwrite and handle route-guard async gap.
  • finally guarantees no stuck edit-modesetEditMode(false) and state = DashboardEditState() always execute regardless of I/O failures.
  • route_usp_dashboard.dart onExit hardenedcancelEditMode() wrapped in try-catch with logger; navigation never blocked on revert failure.
  • linksys_route.dart deleted — Duplicate LinksysRoute using deprecated FeatureState.isDirty API cleanly removed.
  • layoutSnapshot type narrowedList<dynamic>? to List<Map<String,dynamic>>? correctly reflects exportLayout() return type.
  • API rename (commit/cancel) — Clearer semantics than save: bool parameter; all call sites updated consistently.
  • Test suite additions — Re-entrant guard, async-gap cancel, PeterJhong regression, multi-cycle tests comprehensively cover the corrected behavior.
  • Architecture complianceNotifierProvider (non-autoDispose), all ref.read inside notifier, 3-layer boundaries maintained.
  • No security issues — No hardcoded secrets, no injection vectors, no JNAP/USP response handling modifications.

Cross-reviewed by two independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.

@AustinChangLinksys AustinChangLinksys left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated Review — Round 3 · c513381..963db6d (incremental)

Verdict: 💬 Self-review (comment only) — GitHub prohibits self-approval. PeterJhong's _openLayoutSettings regression correctly fixed this round. Three carry-over Warnings remain (W-1, W-2, W-3); W-4 likely unresolved; one new Warning (W-7).

Conf. Where Issue (one-liner)
⚠️ High dashboard_edit_mode_provider.dart:92-108 [both reviewers, CARRY-OVER W-1] saveLayout() exception skips restoreSnapshot() — prefs not reverted on I/O failure, grid/prefs left inconsistent
⚠️ High usp_sliver_dashboard_view.dart:115-121 [both reviewers, CARRY-OVER W-2] _commitEditMode/_cancelEditMode remain void async — exceptions from button-tap path silently discarded
⚠️ High dashboard_edit_mode_provider.dart:89 [both reviewers, CARRY-OVER W-3] _exitEditMode still lacks isEditing guard — concurrent cancel+commit race; enter guard added correctly
⚠️ Med test/.../dashboard_edit_mode_provider_test.dart [single, CARRY-OVER W-4] No isEditing==false idempotency tests for commitEditMode/cancelEditMode
⚠️ High usp_sliver_dashboard_view.dart:514 + settings panel [single, NEW W-7] Widget added via settings panel then Cancel reverts it — data loss path
💡 Med dashboard_edit_mode_provider.dart:14-30 DashboardEditState lacks copyWith — does not follow Type A state model pattern

Confidence: High = code-verified · Med = located + reasoned, not fully confirmed · Low = speculative, please double-check.
Items marked [both reviewers] were independently flagged by two agents → higher confidence.

Resolved from prior rounds
  • PeterJhong regression RESOLVED_openLayoutSettings now calls commitEditMode() instead of exitEditMode(save: false), correctly preserving reset/preset changes. Regression test added.
  • W-5 RESOLVEDtoggle_off removal contextualised; switching handler to commitEditMode() makes absence logical.
  • enterEditMode re-entrant guard RESOLVEDif (state.isEditing) return; + early isEditing: true claim + post-await bail-out correctly handles double-tap race and navigation-during-init async gap.
Warning Details

W-1 [both reviewers, CARRY-OVER] High — saveLayout() exception skips restoreSnapshot()

lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:92-108

try {
  if (revert) {
    if (state.layoutSnapshot != null) {
      controller.importLayout(state.layoutSnapshot!);
      await ref.read(uspSliverDashboardControllerProvider.notifier).saveLayout();
      // throws here → restoreSnapshot SKIPPED below
    }
    if (state.prefsSnapshot != null) {
      await ref.read(uspLayoutPreferencesProvider.notifier).restoreSnapshot(state.prefsSnapshot!);
    }
  }
} finally {
  controller.setEditMode(false);
  state = const DashboardEditState();  // snapshots wiped; no retry possible
}

finally correctly prevents stuck edit mode. But if saveLayout() throws (SharedPreferences I/O failure), restoreSnapshot() is skipped. Post-failure: grid in-memory = reverted to snapshot, grid on-disk = still edited, prefs = still reflect edit-mode values. Three subsystems left inconsistent; snapshots wiped in finally so no retry possible.

Fix: Separate try/catch per awaitable so restoreSnapshot always runs:

if (state.layoutSnapshot != null) {
  controller.importLayout(state.layoutSnapshot!);
  try {
    await ref.read(uspSliverDashboardControllerProvider.notifier).saveLayout();
  } catch (e, s) {
    logger.e('[Dashboard] saveLayout failed during revert', error: e, stackTrace: s);
  }
}
if (state.prefsSnapshot != null) {
  await ref.read(uspLayoutPreferencesProvider.notifier).restoreSnapshot(state.prefsSnapshot!);
}

W-2 [both reviewers, CARRY-OVER] High — _commitEditMode/_cancelEditMode remain void async

lib/page/dashboard/views/usp_sliver_dashboard_view.dart:115-121

void _commitEditMode() async {
  await ref.read(dashboardEditModeProvider.notifier).commitEditMode();
}

void _cancelEditMode() async {
  await ref.read(dashboardEditModeProvider.notifier).cancelEditMode();
}

Both remain void async without try/catch. Any exception propagates into an unhandled Future and is silently swallowed. Route onExit correctly wraps cancelEditMode() in try/catch; button-tap paths do not.

Fix: Add try/catch with logger.e to both wrappers (same pattern as route_usp_dashboard.dart).


W-3 [both reviewers, CARRY-OVER] High — _exitEditMode lacks isEditing guard

lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:89

enterEditMode() has the correct re-entrant guard. _exitEditMode still has no symmetric guard. Concurrent path: user taps commit while route onExit fires simultaneously. Both enter _exitEditMode; the second call reaches controller.setEditMode(false) a second time and creates redundant state churn.

Fix:

Future<void> _exitEditMode({required bool revert}) async {
  if (!state.isEditing) return;  // symmetric guard matching enterEditMode
  // ... rest unchanged
}

W-4 [single, Reviewer A, CARRY-OVER] Med — Missing isEditing==false idempotency tests

test/page/dashboard/providers/dashboard_edit_mode_provider_test.dart

Tests added this round cover: PeterJhong regression, re-entrant enter guard, async-gap cancel, multi-cycle. None test calling commitEditMode() or cancelEditMode() when isEditing == false. (Reviewer A read the test file directly and confirmed absence; Reviewer B marked resolved without access to the PR branch.)

Fix: Add two tests verifying safe no-op behavior when not in edit mode.


W-7 [single, Reviewer A, NEW] High — Widget added via settings panel then Cancel reverts it

lib/page/dashboard/views/usp_sliver_dashboard_view.dart:514-519 + usp_layout_settings_panel.dart

if ((result == 'reset' || result == 'preset_changed') && mounted) {
  await ref.read(dashboardEditModeProvider.notifier).commitEditMode();
}

When the settings panel Close button pops with no argument, result == null. Condition is false; commitEditMode() not called. Inside the panel, addWidget() calls saveLayout() immediately — widget is persisted to disk. But snapshot captured in enterEditMode() does not include the new widget. If user then taps Cancel, cancelEditMode() restores the snapshot — widget removed from memory and disk despite having been saved.

Concrete trigger: Edit mode → Settings panel → "+" add widget → Close (no result) → tap Cancel → widget disappears.

Fix (Option 1): Pop with 'widget_added' after addWidget() in the settings panel; add that string to the condition in _openLayoutSettings.

Fix (Option 2): Add refreshSnapshot() to the notifier and call it after widget mutations inside the settings panel.

What looks good
  • PeterJhong regression correctly fixedcommitEditMode() preserves reset/preset changes. Regression test added.
  • enterEditMode re-entrant guard — correct order: guard, early state claim, await, post-await bail-out.
  • try/finally guarantees no stuck edit modesetEditMode(false) and state always cleared regardless of I/O failures.
  • Route onExit error handling — try/catch with structured logging; navigation never blocked.
  • Type narrowingList<dynamic> to List<Map<String, dynamic>> surfaces type errors at compile time.
  • linksys_route.dart deletion — dead file using deprecated FeatureState.isDirty correctly removed.
  • New tests — PeterJhong regression, re-entrant guard, async-gap cancel, multi-cycle tests well-targeted.
  • No security issues — no hardcoded secrets, injection vectors, or JNAP/USP response handling modifications.
  • Architecture complianceNotifierProvider (non-autoDispose), ref.read inside notifier, 3-layer boundaries maintained.

Cross-reviewed by two independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.

@AustinChangLinksys AustinChangLinksys left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated Review — Round 4 · c513381..963db6d (incremental)

Verdict: 💬 Self-review (comment only) — GitHub prohibits self-approval. PeterJhong's regression (_openLayoutSettings revert path) is correctly fixed this round. Four carry-over Warnings remain (W-1, W-2, W-3, W-4); W-7 is contested — one reviewer considers it resolved, the other sees a residual data-loss edge case.

Conf. Where Issue (one-liner)
⚠️ 🟢High dashboard_edit_mode_provider.dart:93-104 [both reviewers] saveLayout() exception skips restoreSnapshot() — grid reverted in-memory but prefs left inconsistent
⚠️ 🟢High usp_sliver_dashboard_view.dart:115-121 [both reviewers] _commitEditMode/_cancelEditMode remain void async without try/catch — UI button exceptions silently discarded
⚠️ 🟢High dashboard_edit_mode_provider.dart:89 [both reviewers] _exitEditMode lacks isEditing guard — double-call (button + route onExit) can execute two concurrent reverts
⚠️ 🟡Med test/…/dashboard_edit_mode_provider_test.dart [both reviewers] No isEditing==false idempotency tests for commitEditMode/cancelEditMode
⚠️ 🟡Med usp_sliver_dashboard_view.dart:514 + settings panel [single — contested] Widget added via + in settings panel, then Close (null result) + Cancel = widget silently dropped (Reviewer B considers this correct cancel semantics)

Confidence: 🟢High = code-verified · 🟡Med = located + reasoned, not fully confirmed · ⚪Low = speculative, please double-check.
Items marked [both reviewers] were independently flagged by two agents → higher confidence.

✅ Resolved this round
  • PeterJhong regression RESOLVED_openLayoutSettings (line 514) now calls commitEditMode() for reset and preset_changed results, correctly keeping the applied change instead of reverting to pre-edit snapshot. Regression test added.
  • toggle_off removal confirmed safe — global search finds zero remaining toggle_off strings in .dart files; no matching pop calls exist.
  • linksys_route.dart deletion confirmed safeLinksysRoute class was already migrated to route_model.dart using PreservableContract; no dangling imports found.
  • DashboardEditState type narrowinglayoutSnapshot now typed List<Map<String, dynamic>>? (narrowed from List<dynamic>?) and copyWith updated consistently.
  • enterEditMode re-entrant guard + async-gap bail-outif (state.isEditing) return at line 50 plus if (!state.isEditing) return post-await at line 60 correctly handle double-tap race and navigation-during-init.
⚠️ Warning Details

W-1 [both reviewers, CARRY-OVER] 🟢High — saveLayout() exception skips restoreSnapshot()

lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:93-104

try {
  if (revert) {
    if (state.layoutSnapshot != null) {
      controller.importLayout(state.layoutSnapshot!);
      await ref.read(uspSliverDashboardControllerProvider.notifier).saveLayout();
      // if this throws, restoreSnapshot() below is NEVER reached
    }
    if (state.prefsSnapshot != null) {
      await ref.read(uspLayoutPreferencesProvider.notifier).restoreSnapshot(state.prefsSnapshot!);
    }
  }
} finally {
  controller.setEditMode(false);
  state = const DashboardEditState(); // snapshots wiped; no retry possible
}

The finally block correctly prevents stuck edit mode. However, if saveLayout() throws (SharedPreferences/localStorage I/O failure), restoreSnapshot() is skipped. Post-failure: grid in-memory is reverted to snapshot, grid on-disk still reflects edited state, prefs still reflect edit-mode values. Three subsystems are now inconsistent; snapshots wiped in finally so no retry is possible.

Fix: Give each awaitable its own try/catch so restoreSnapshot always runs:

if (revert) {
  if (state.layoutSnapshot != null) {
    controller.importLayout(state.layoutSnapshot!);
    try {
      await ref.read(uspSliverDashboardControllerProvider.notifier).saveLayout();
    } catch (e, s) {
      logger.e('[Dashboard] saveLayout failed during cancel', error: e, stackTrace: s);
    }
  }
  if (state.prefsSnapshot != null) {
    await ref.read(uspLayoutPreferencesProvider.notifier).restoreSnapshot(state.prefsSnapshot!);
  }
}

W-2 [both reviewers, CARRY-OVER] 🟢High — _commitEditMode/_cancelEditMode remain void async

lib/page/dashboard/views/usp_sliver_dashboard_view.dart:115-121

void _commitEditMode() async {
  await ref.read(dashboardEditModeProvider.notifier).commitEditMode();
}

void _cancelEditMode() async {
  await ref.read(dashboardEditModeProvider.notifier).cancelEditMode();
}

Both widget-layer wrappers are void async without try/catch. Any exception from the provider is silently discarded. The route's onExit wrapper (route_usp_dashboard.dart:35-44) correctly catches and logs — button-tap paths do not.

Fix: Add try/catch with logger.e (matching route pattern):

void _commitEditMode() async {
  try {
    await ref.read(dashboardEditModeProvider.notifier).commitEditMode();
  } catch (e, s) {
    logger.e('[Dashboard] commitEditMode failed', error: e, stackTrace: s);
  }
}

W-3 [both reviewers, CARRY-OVER] 🟢High — _exitEditMode lacks isEditing guard

lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:89

enterEditMode() has a re-entrant guard (if (state.isEditing) return). _exitEditMode has no symmetric guard. Double-call scenario: user taps Cancel button while route onExit fires simultaneously. Both calls enter _exitEditMode with state.isEditing == true; both execute importLayout() + saveLayout() in parallel, racing on the same storage key. Second call also sees non-null prefsSnapshot (first call's finally hasn't run yet) and triggers a second restoreSnapshot.

Fix:

Future<void> _exitEditMode({required bool revert}) async {
  if (!state.isEditing) return; // symmetric guard matching enterEditMode
  // ... rest unchanged
}

W-4 [both reviewers, CARRY-OVER] 🟡Med — Missing isEditing==false idempotency tests

test/page/dashboard/providers/dashboard_edit_mode_provider_test.dart

New tests this round cover re-entrant enter, async-gap cancel, and commit-preserves-changes. None test calling commitEditMode() or cancelEditMode() when isEditing == false — the no-op/idempotency behavior when not in edit mode is not verified.

Fix: Add two tests:

test('commitEditMode when not editing is a no-op', () async {
  final container = await createContainer();
  await container.read(dashboardEditModeProvider.notifier).commitEditMode();
  expect(container.read(dashboardEditModeProvider).isEditing, isFalse);
});
test('cancelEditMode when not editing is a no-op', () async {
  final container = await createContainer();
  await container.read(dashboardEditModeProvider.notifier).cancelEditMode();
  expect(container.read(dashboardEditModeProvider).isEditing, isFalse);
});

W-7 [single — CONTESTED] 🟡Med — Widget added via settings panel then Cancel

lib/page/dashboard/views/usp_sliver_dashboard_view.dart:514-518

if ((result == 'reset' || result == 'preset_changed') && mounted) {
  await ref.read(dashboardEditModeProvider.notifier).commitEditMode();
}
// When user adds a widget (+) then closes the panel (result == null):
// commitEditMode() is NOT called; if user taps Cancel → widget reverted

Reviewer A: Data-loss edge case. The user adds a widget (persisted to disk by addWidget()), closes the panel without triggering 'reset'/'preset_changed', then taps Cancel — the widget disappears despite having been explicitly added.

Reviewer B: Cancel is designed to revert all edit-mode changes to the pre-edit snapshot. A widget added during edit mode reverting on Cancel is correct semantics; the snapshot accurately reflects the pre-edit state.

Synthesizer note: Austin to decide — should widgets added via the settings panel be treated as committed sub-actions that survive a subsequent Cancel? If yes, add a refreshSnapshot() call after addWidget(). If Cancel should always restore the pre-edit baseline, current behavior is correct.

✅ What looks good
  • _exitEditMode finally blockfinally guarantees controller.setEditMode(false) + state reset regardless of exceptions; dashboard cannot be stranded in edit mode.
  • enterEditMode double-guard pattern (lines 50 + 60) — claim isEditing: true before the first await; bail out post-await if cancelled. Both guards are correct and well-commented.
  • commitEditMode/cancelEditMode semantic rename — eliminates the save: false inverted-boolean trap; API intent is now unambiguous.
  • PeterJhong regression fixcommitEditMode() used correctly for reset/preset_changed path; toggle_off dead branch cleanly removed.
  • layoutSnapshot type narrowingList<dynamic>?List<Map<String, dynamic>>? reduces implicit cast risk.
  • route_usp_dashboard.dart onExit try/catch — catches and logs without blocking navigation; correct strategy.
  • New regression test suite — re-entrant enter, async-gap cancel, and commit-preserves-changes tests are high-quality behavioral regression guards.
  • linksys_route.dart deletion — dead code removed cleanly.
  • Security — no hardcoded secrets, no injection vectors, no permission changes, no JNAP/USP response handling modifications.

Cross-reviewed by two independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.

@AustinChangLinksys AustinChangLinksys left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated Review — Round 5 · c513381..963db6d (incremental)

Verdict: 💬 Self-review (comment only) — GitHub prohibits self-approval. PeterJhong's regression (_openLayoutSettings revert path) is correctly fixed this round. Four carry-over Warnings remain open (W-1, W-2, W-3, W-4); one new Warning from architecture review (ARCH-04).

Conf. Where Issue (one-liner)
⚠️ 🟢High dashboard_edit_mode_provider.dart:93-104 [both reviewers, CARRY-OVER W-1] saveLayout() exception still skips restoreSnapshot() — prefs diverge from grid on I/O failure
⚠️ 🟢High usp_sliver_dashboard_view.dart:115-121 [both reviewers, CARRY-OVER W-2] _commitEditMode/_cancelEditMode are still void async without try/catch — button-tap exceptions silently discarded
⚠️ 🟢High dashboard_edit_mode_provider.dart:89 [both reviewers, CARRY-OVER W-3] _exitEditMode has no isEditing guard — double-call (button + route onExit) still races on SharedPreferences write
⚠️ 🟡Med test/…/dashboard_edit_mode_provider_test.dart [both reviewers, CARRY-OVER W-4] No idempotency tests for commitEditMode/cancelEditMode when isEditing == false
⚠️ 🟡Med usp_sliver_dashboard_view.dart:514 [single — architecture] _openLayoutSettings calls commitEditMode() on reset/preset_changed — silently exits edit mode; user cannot keep adjusting layout after applying a preset
💡 🟢High dashboard_edit_mode_provider.dart:52-65 isEditing: true emitted ~100ms before controller.setEditMode(true) — brief window where edit UI shows but drags are silently rejected
💡 🟢High dashboard_edit_mode_provider.dart (_exitEditMode) controller.setEditMode(false) called unconditionally even when !isEditing — add a comment documenting intent (or add guard, resolves W-3)
💡 🟡Med lib/route/linksys_route.dart (deleted) Deletion is safe (no imports confirmed) — run flutter analyze to confirm no build-target artifacts

Confidence: 🟢High = code-verified · 🟡Med = located + reasoned, not fully confirmed · ⚪Low = speculative, please double-check.
Items marked [both reviewers] were independently flagged by two agents → higher confidence.

✅ Resolved this round
  • PeterJhong regression RESOLVED_openLayoutSettings (line 514) now calls commitEditMode() for reset and preset_changed results, correctly keeping the applied change instead of reverting to pre-edit snapshot. toggle_off branch removed (confirmed dead — settings panel never pops this value).
  • W-1 controller cleanup RESOLVED_exitEditMode wraps work in try/finally; controller.setEditMode(false) and state = const DashboardEditState() are now guaranteed to execute even when saveLayout() or restoreSnapshot() throws. Dashboard can no longer be stranded in edit mode on I/O failure.
  • Re-entrant enterEditMode RESOLVEDif (state.isEditing) return guard at line 50 prevents double-tap/gesture race from overwriting the original snapshot. Post-await if (!state.isEditing) return bail-out correctly handles cancellation during the initialized await.
  • DashboardEditState.layoutSnapshot type tightened — narrowed from List<dynamic>? to List<Map<String, dynamic>>?. Verified that sliver_dashboard 0.9.1 declares List<Map<String, dynamic>> exportLayout() — this narrowing is safe and compiles cleanly.
  • New regression tests addedcommitEditMode-preserves-changes (PeterJhong regression guard), re-entrant-enter (W-1 guard), cancel-during-async-gap (W-2 guard).
  • linksys_route.dart deletion confirmed safeLinksysRoute active implementation lives in route_model.dart; zero imports of the deleted file across the codebase.
⚠️ Warning Details

W-1 [both reviewers, CARRY-OVER] 🟢High — saveLayout() throw still skips restoreSnapshot()

lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:93-104

try {
  if (revert) {
    if (state.layoutSnapshot != null) {
      controller.importLayout(state.layoutSnapshot!);
      await ref.read(...).saveLayout();         // ← if THIS throws …
    }
    if (state.prefsSnapshot != null) {
      await ref.read(...).restoreSnapshot(...); // ← … STILL skipped
    }
  }
} finally {
  controller.setEditMode(false);   // ✅ now guaranteed
  state = const DashboardEditState(); // ✅ now guaranteed
}

The finally block correctly prevents stuck edit mode (improvement). But if saveLayout() throws (SharedPreferences/localStorage I/O failure), restoreSnapshot() is skipped. Post-failure: grid in-memory is reverted to snapshot, grid on-disk still reflects edited state, prefs (selectedPreset) still reflect the in-progress edit. Three subsystems are inconsistent; snapshots wiped in finally so no retry possible.

Fix: Give each awaitable its own try/catch so restoreSnapshot always runs regardless of saveLayout success:

if (state.layoutSnapshot != null) {
  controller.importLayout(state.layoutSnapshot!);
  try {
    await ref.read(uspSliverDashboardControllerProvider.notifier).saveLayout();
  } catch (e, s) {
    logger.e('[Dashboard] saveLayout failed during cancel', error: e, stackTrace: s);
  }
}
if (state.prefsSnapshot != null) {
  await ref.read(uspLayoutPreferencesProvider.notifier).restoreSnapshot(state.prefsSnapshot!);
}

W-2 [both reviewers, CARRY-OVER] 🟢High — _commitEditMode/_cancelEditMode bare void async

lib/page/dashboard/views/usp_sliver_dashboard_view.dart:115-121

void _commitEditMode() async {
  await ref.read(dashboardEditModeProvider.notifier).commitEditMode(); // no try/catch
}
void _cancelEditMode() async {
  await ref.read(dashboardEditModeProvider.notifier).cancelEditMode(); // no try/catch
}

The provider's finally block prevents state corruption, but a saveLayout() I/O failure during revert will propagate out through cancelEditMode()_cancelEditMode() → silently consumed by Flutter's unhandled-async zone. The route guard (same commit) correctly wraps the same call with try/catch + logger.e. The two call-sites have inconsistent error-handling discipline.

Fix: Add try/catch matching the route guard pattern:

void _cancelEditMode() async {
  try {
    await ref.read(dashboardEditModeProvider.notifier).cancelEditMode();
  } catch (e, s) {
    logger.e('[Dashboard] cancelEditMode failed', error: e, stackTrace: s);
  }
}

W-3 [both reviewers, CARRY-OVER] 🟢High — _exitEditMode lacks isEditing guard

lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:89

enterEditMode() now has a symmetric re-entrant guard (if (state.isEditing) return). _exitEditMode has no matching if (!state.isEditing) return.

Double-exit scenario: user taps ✗ Cancel while route onExit fires simultaneously. Both calls enter _exitEditMode(revert: true) with state.isEditing == true. Both read state.layoutSnapshot (non-null — first call's finally has not yet run), both execute importLayout() + saveLayout() concurrently, racing on the same SharedPreferences key.

Fix:

Future<void> _exitEditMode({required bool revert}) async {
  if (!state.isEditing) return; // symmetric guard matching enterEditMode
  // ... rest unchanged
}

W-4 [both reviewers, CARRY-OVER] 🟡Med — Missing idempotency tests

test/page/dashboard/providers/dashboard_edit_mode_provider_test.dart

New tests cover re-entrant enter, async-gap cancel, and commit-preserves-changes. None verify that calling commitEditMode() or cancelEditMode() when isEditing == false is a no-op (or at minimum doesn't corrupt state). Without such a test, the W-3 double-call scenario and the controller.setEditMode(false) unconditional call are invisible to CI.

Fix: Add two tests:

test('commitEditMode when not editing is a no-op', () async { ... });
test('cancelEditMode when not editing is a no-op', () async { ... });

ARCH-04 [single — architecture] 🟡Med — _openLayoutSettings exits edit mode on reset/preset_changed

lib/page/dashboard/views/usp_sliver_dashboard_view.dart:514

if ((result == 'reset' || result == 'preset_changed') && mounted) {
  // Commit (keep) the change — the settings panel already applied the
  // reset/preset directly to the controller and prefs …
  await ref.read(dashboardEditModeProvider.notifier).commitEditMode();
}

commitEditMode() exits edit mode entirely. A user who opens Settings → selects a new preset → closes the panel is silently returned to the non-editing dashboard state, instead of back to edit mode. The bug that PeterJhong flagged (changes were reverted) is fixed, but the flow now ejects the user from edit mode without indication. This is a second-order UX regression — consider returning null from the panel close to keep edit mode alive, rather than terminating it, unless fully exiting is the intended design.

✅ What looks good
  • No security issues — no hardcoded secrets, no injection vectors, no JNAP/USP response handling touched.
  • finally block is correct — state always resets, edit mode never gets stranded on I/O failure.
  • Re-entrant enterEditMode guardif (state.isEditing) return + pre-await isEditing: true claim + post-await bail-out is the right pattern for this async lifecycle.
  • commitEditMode()/cancelEditMode() naming — explicit names are clearer than exitEditMode({bool save}) which was an inverted-boolean trap.
  • Route onExit hardened correctly — try/catch + logger.e + always returns true (never blocks navigation) is the correct pattern.
  • Test coverage for the regressioncommitEditMode-preserves-changes directly guards against the PeterJhong regression reoccurring.
  • layoutSnapshot type tighteningList<Map<String, dynamic>>? is correct; verified against sliver_dashboard 0.9.1 which declares List<Map<String, dynamic>> exportLayout().

Cross-reviewed by two independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.

@AustinChangLinksys AustinChangLinksys left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated Review — Round 6 · c513381..963db6d (incremental)

Verdict: 💬 Self-review (comment only) — GitHub prohibits self-approval. Three carry-over Warnings from Round 5 are now fully resolved (W-1, W-2, W-3). ARCH-04 is resolved. W-4 (idempotency tests) remains open. Two new Warnings raised by both reviewers independently; two additional Warnings from single reviewers.

Conf. Where Issue (one-liner)
⚠️ 🟢High dashboard_edit_mode_provider.dart:92-104 [both reviewers] saveLayout() throw inside single try still skips restoreSnapshot() — prefs diverge from grid on I/O failure
⚠️ 🟢High usp_sliver_dashboard_view.dart:115-121 [both reviewers] _commitEditMode/_cancelEditMode are void async with no error logging — I/O failures silently discarded at view layer
⚠️ 🟡Med dashboard_edit_mode_provider.dart:89 [single — architecture] _exitEditMode has no isEditing guard — called outside edit mode causes spurious Riverpod rebuilds every tab-leave
⚠️ 🟡Med dashboard_edit_mode_provider.dart:54-60 [single — security] enterEditMode captures snapshots after await initialized; cancel during async gap skips revert (null snapshots)
⚠️ 🟡Med test/…/dashboard_edit_mode_provider_test.dart [both reviewers, CARRY-OVER W-4] No idempotency tests for commitEditMode/cancelEditMode when isEditing == false
💡 🟢High usp_layout_settings_panel.dart:127 as Map cast inconsistent with PR's type-tightening to Map<String, dynamic> — use e['id'] directly
💡 🟡Med lib/route/linksys_route.dart (deleted) PR description says "removed" but LinksysRoute lives on in route_model.dart — misleading commit note

Confidence: 🟢High = code-verified · 🟡Med = located + reasoned, not fully confirmed · ⚪Low = speculative, please double-check.
Items marked [both reviewers] were independently flagged by two agents → higher confidence.

✅ Resolved this round (W-1, W-2, W-3, ARCH-04)
  • W-1 [both reviewers] RESOLVED_exitEditMode now wraps both revert operations in try/finally. controller.setEditMode(false) and state = DashboardEditState() are guaranteed to run even if saveLayout() or restoreSnapshot() throws — dashboard can no longer be stranded in edit mode on I/O failure. (Residual: W-1 carry-over re-emerges as N-1 below — the finally solves "stuck in edit mode" but not "prefs diverge from grid".)
  • W-2 [both reviewers] RESOLVED — State integrity is now owned by the provider layer (try/finally). The view's _commitEditMode/_cancelEditMode correctly delegate; provider guarantees state cleanup. Route-level onExit has its own try/catch + logger.e for the high-stakes path. (New logging gap remains — see N-2.)
  • W-3 [both reviewers] RESOLVEDenterEditMode() has re-entrant guard at line 50 (if (state.isEditing) return) and post-await bail-out at line 60 (if (!state.isEditing) return). Both synchronous double-tap race and navigation-during-async-gap race are covered. Calling _exitEditMode when isEditing == false is safe (revert path short-circuits on null snapshots; finally writes are idempotent). (Spurious rebuild concern noted as N-3.)
  • ARCH-04 [single — architecture] RESOLVED_openLayoutSettings now calls commitEditMode() (keep) instead of exitEditMode(save: false) (revert) for reset/preset_changed results. toggle_off branch correctly removed (confirmed no hit in codebase — was always a dead path). PeterJhong's regression is fixed.
  • Re-entrant enterEditMode RESOLVED — Pre-await isEditing: true claim + post-await bail-out correctly handles both the double-tap race and the navigate-during-init-gap scenario.
  • layoutSnapshot type tightened — Narrowed from List<dynamic>? to List<Map<String, dynamic>>?; consistent with sliver_dashboard 0.9.1's exportLayout() return type.
  • PeterJhong regression fully addressedcommitEditMode-preserves-changes test directly guards against recurrence.
⚠️ Warning Details

N-1 [both reviewers] 🟢High — Sequential revert steps share one try; saveLayout() throw still skips restoreSnapshot()

lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:92–104

try {
  if (revert) {
    if (state.layoutSnapshot != null) {
      controller.importLayout(state.layoutSnapshot!);
      await ref.read(uspSliverDashboardControllerProvider.notifier).saveLayout(); // ← if this throws…
    }
    if (state.prefsSnapshot != null) {
      await ref.read(uspLayoutPreferencesProvider.notifier).restoreSnapshot(state.prefsSnapshot!); // ← …skipped
    }
  }
} finally {
  controller.setEditMode(false); // ✅ always runs
  state = const DashboardEditState(); // ✅ always runs
}

The finally correctly prevents stuck edit mode. But when saveLayout() throws (SharedPreferences/localStorage I/O failure during cancel), restoreSnapshot() is never reached. Post-failure: grid is reverted in-memory, grid on-disk still reflects edited state, prefs (selectedPreset) still reflect the in-progress edit — three subsystems inconsistent. Snapshots wiped in finally so no retry possible.

Trigger: SharedPreferences I/O failure (quota exceeded, corrupted storage, race with another write) during cancel-edit-mode on Android/iOS.

Fix: Wrap each awaitable independently:

if (state.layoutSnapshot != null) {
  controller.importLayout(state.layoutSnapshot!);
  try {
    await ref.read(uspSliverDashboardControllerProvider.notifier).saveLayout();
  } catch (e, s) {
    logger.e('[Dashboard] saveLayout failed during revert', error: e, stackTrace: s);
  }
}
if (state.prefsSnapshot != null) {
  await ref.read(uspLayoutPreferencesProvider.notifier).restoreSnapshot(state.prefsSnapshot!);
}

N-2 [both reviewers] 🟢High — _commitEditMode/_cancelEditMode bare void async with no error logging

lib/page/dashboard/views/usp_sliver_dashboard_view.dart:115–121

void _commitEditMode() async {
  await ref.read(dashboardEditModeProvider.notifier).commitEditMode(); // no try/catch
}
void _cancelEditMode() async {
  await ref.read(dashboardEditModeProvider.notifier).cancelEditMode(); // no try/catch
}

Provider's finally prevents state corruption but any exception escaping commitEditMode()/cancelEditMode() propagates into the void async frame and is silently consumed by Flutter's unhandled-async zone — no log, no user feedback. The route guard (route_usp_dashboard.dart:27–35) has try/catch + logger.e; the two button-tap call sites have inconsistent error-handling discipline.

Fix: Add try/catch matching the route guard pattern:

void _cancelEditMode() async {
  try {
    await ref.read(dashboardEditModeProvider.notifier).cancelEditMode();
  } catch (e, s) {
    logger.e('[Dashboard] cancelEditMode failed', error: e, stackTrace: s);
  }
}
void _commitEditMode() async {
  try {
    await ref.read(dashboardEditModeProvider.notifier).commitEditMode();
  } catch (e, s) {
    logger.e('[Dashboard] commitEditMode failed', error: e, stackTrace: s);
  }
}

N-3 [single — architecture] 🟡Med — _exitEditMode lacks isEditing guard; spurious Riverpod rebuilds on every tab-leave

lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:89

enterEditMode() has a symmetric re-entrant guard (if (state.isEditing) return). _exitEditMode has no matching if (!state.isEditing) return. When called outside edit mode (e.g., if a route guard fires on a tab where the user never entered edit mode), the finally block unconditionally calls controller.setEditMode(false) and assigns state = const DashboardEditState() — triggering Riverpod listener rebuilds even when nothing changed.

Fix:

Future<void> _exitEditMode({required bool revert}) async {
  if (!state.isEditing) return; // symmetric guard matching enterEditMode
  // ... rest unchanged
}

Note: route_usp_dashboard.dart:34 already gates if (editState.isEditing) before calling cancelEditMode(), so the route path is safe. The guard in the provider itself would protect future callers.


N-4 [single — security] 🟡Med — Snapshot is null during await initialized async gap; cancel during gap skips layout revert

lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:54–60

// Line 54: isEditing = true, layoutSnapshot = null, prefsSnapshot = null
state = const DashboardEditState(isEditing: true);

await ref.read(uspLayoutPreferencesProvider.notifier).initialized; // async gap

// Line 60: post-await bailout
if (!state.isEditing) return;

// Lines 62-70: snapshots captured HERE (after await)
final layoutSnapshot = controller.exportLayout();

If cancelEditMode() fires in the async gap (navigation away before initialized resolves), _exitEditMode(revert: true) runs with state.layoutSnapshot == null — the if (state.layoutSnapshot != null) guard short-circuits, so importLayout + restoreSnapshot are skipped. Any grid changes made between enterEditMode() and initialized resolving will not be reverted.

Trigger: User taps Edit on first launch (prefs cold, initialized is slow) → modifies a card → navigates away before prefs resolve. Grid state is not reverted.

Fix: Capture the layout snapshot synchronously before the await:

final controller = ref.read(uspSliverDashboardControllerProvider);
final earlyLayoutSnapshot = controller.exportLayout();
final earlyPrefsSnapshot = ref.read(uspLayoutPreferencesProvider);
state = DashboardEditState(isEditing: true, layoutSnapshot: earlyLayoutSnapshot, prefsSnapshot: earlyPrefsSnapshot);

await ref.read(uspLayoutPreferencesProvider.notifier).initialized;
if (!state.isEditing) return;

// Refresh prefs snapshot post-initialization
final refreshedPrefs = ref.read(uspLayoutPreferencesProvider);
state = state.copyWith(prefsSnapshot: refreshedPrefs);
controller.setEditMode(true);

W-4 [both reviewers, CARRY-OVER] 🟡Med — No idempotency tests for commitEditMode/cancelEditMode when isEditing == false

test/page/dashboard/providers/dashboard_edit_mode_provider_test.dart

New tests cover re-entrant enter, async-gap cancel, commit-preserves-changes, and multiple enter/commit cycles. None verify that calling commitEditMode() or cancelEditMode() when isEditing == false is a safe no-op (does not corrupt state or throw). The behavioral contract is untested; a future refactor adding a guard would have no regression safety net.

Fix:

test('commitEditMode when not editing is a no-op', () async {
  final container = await createContainer();
  addTearDown(container.dispose);
  await container.read(dashboardEditModeProvider.notifier).commitEditMode();
  final state = container.read(dashboardEditModeProvider);
  expect(state.isEditing, isFalse);
  expect(state.layoutSnapshot, isNull);
});
test('cancelEditMode when not editing is a no-op', () async { ... });
✅ What looks good
  • No security issues — no hardcoded secrets, no injection vectors, no JNAP/USP response handling touched.
  • finally block is correct — state always resets, edit mode never gets stranded on I/O failure.
  • Re-entrant enterEditMode guardif (state.isEditing) return + pre-await isEditing: true claim + post-await bail-out is the right pattern for this async lifecycle.
  • commitEditMode()/cancelEditMode() naming — explicit names are clearer than exitEditMode({bool save}) inverted-boolean trap.
  • Route onExit hardened correctlytry/catch + logger.e + always return true is the correct pattern.
  • _openLayoutSettings fix is correctcommitEditMode() for reset/preset_changed preserves the applied change; toggle_off removal confirmed dead path.
  • Test coverage for regressioncommitEditMode-preserves-changes directly guards against PeterJhong regression.
  • layoutSnapshot type tighteningList<Map<String, dynamic>>? correct; consistent with sliver_dashboard 0.9.1.
  • linksys_route.dart deletionLinksysRoute lives on in route_model.dart; file was a standalone re-export that is correctly consolidated.
  • PeterJhong regression addressed_openLayoutSettings now calls commitEditMode() (keep) for reset/preset_changed results.

Cross-reviewed by two independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.

@AustinChangLinksys AustinChangLinksys left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated Review — Round 2 · c513381..963db6d (incremental)

Verdict: 💬 Self-review (comment only) — GitHub prohibits self-approval. PeterJhong's _openLayoutSettings bug (calling revert instead of commit on reset/preset) is correctly fixed in this round. W4 (async-gap race) is also resolved. Four prior Warnings remain open; two new Warnings and two Suggestions identified.

Conf. Where Issue (one-liner)
⚠️ 🟢High dashboard_edit_mode_provider.dart:92–109 [both reviewers] saveLayout() throw inside single try still skips restoreSnapshot() — partial revert on I/O failure leaves grid/prefs inconsistent
⚠️ 🟢High usp_sliver_dashboard_view.dart:115–121 [both reviewers] _commitEditMode/_cancelEditMode button-tap handlers are void async with no try/catch — I/O failures silently discarded at view layer
⚠️ 🟡Med dashboard_edit_mode_provider.dart (N1) [both reviewers] Edit UI renders (isEditing=true) before controller.setEditMode(true) — drag/resize gestures silently rejected during async gap on slow devices
⚠️ 🟡Med dashboard_edit_mode_provider.dart:54–72 [single — security] Snapshots captured only after await initialized; if cancelled during gap, setEditMode(false) called on a controller never put into edit mode
💡 🟢High dashboard_edit_mode_provider.dart [both reviewers, CARRY-OVER W-5] No idempotency tests for commitEditMode/cancelEditMode when isEditing == false
💡 🟢High dashboard_edit_mode_provider.dart [single — architecture] _exitEditMode has no isEditing guard — called outside edit mode causes spurious Riverpod state emission and unnecessary setEditMode(false) call
💡 🟢High usp_sliver_dashboard_view.dart:499–519 [single — architecture] _openLayoutSettings null-result (dialog dismissed without reset/preset) leaves user in edit mode with no documentation of this design intent
💡 🟡Med usp_layout_settings_panel.dart:127 [single] (e as Map)['id'] cast inconsistent with PR's type-tightening of layoutSnapshot to List<Map<String, dynamic>>? — erased type risks TypeError if id absent
💡 ⚪Low test/…/dashboard_edit_mode_provider_test.dart [single — architecture] No widget test for _openLayoutSettingscommitEditMode() path — PeterJhong regression unguarded at view layer

Confidence: 🟢High = code-verified · 🟡Med = located + reasoned, not fully confirmed · ⚪Low = speculative, please double-check.
Items marked [both reviewers] were independently flagged by two agents → higher confidence.

⚠️ Warning Details

W-1 · [both reviewers] 🟢High — saveLayout() throw skips restoreSnapshot() on cancel path

lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:92–109

try {
  if (revert) {
    if (state.layoutSnapshot != null) {
      controller.importLayout(state.layoutSnapshot!);  // grid mutated in-memory
      await ref
          .read(uspSliverDashboardControllerProvider.notifier)
          .saveLayout();                                // ← if this throws…
    }
    if (state.prefsSnapshot != null) {
      await ref
          .read(uspLayoutPreferencesProvider.notifier)
          .restoreSnapshot(state.prefsSnapshot!);      // …this is never reached
    }
  }
} finally {
  controller.setEditMode(false);   // ✅ always runs
  state = const DashboardEditState();
}

Trigger: SharedPreferences/localStorage I/O failure (quota exceeded, corrupted storage, write race) during saveLayout() on Android/iOS while cancelling edit mode.

Consequence: importLayout() has mutated the in-memory grid back to snapshot. saveLayout() throws; restoreSnapshot() is skipped. After finally: edit mode exits correctly (✅), but the persisted layout key reflects the last-drag edit value while UspLayoutPreferences retains the edit-mode prefs — the two subsystems are now diverged. prefsSnapshot is wiped in finally, so no retry is possible on next load.

Fix: Separate the two revert operations into independent try/catch blocks:

if (revert) {
  if (state.layoutSnapshot != null) {
    controller.importLayout(state.layoutSnapshot!);
    try {
      await ref.read(uspSliverDashboardControllerProvider.notifier).saveLayout();
    } catch (e, s) {
      logger.e('[Dashboard] saveLayout failed during revert', error: e, stackTrace: s);
    }
  }
  if (state.prefsSnapshot != null) {
    await ref.read(uspLayoutPreferencesProvider.notifier).restoreSnapshot(state.prefsSnapshot!);
  }
}

W-2 · [both reviewers] 🟢High — View button-tap handlers _commitEditMode/_cancelEditMode swallow exceptions silently

lib/page/dashboard/views/usp_sliver_dashboard_view.dart:115–121

void _commitEditMode() async {
  await ref.read(dashboardEditModeProvider.notifier).commitEditMode(); // no try/catch
}

void _cancelEditMode() async {
  await ref.read(dashboardEditModeProvider.notifier).cancelEditMode(); // no try/catch
}

The route guard at route_usp_dashboard.dart:32–44 correctly wraps cancelEditMode() in try/catch(e, s) with logger.e. The two button-tap call sites are inconsistent — any exception escaping _exitEditMode propagates into the void async frame and is silently consumed by Flutter's unhandled-async zone with no log entry.

Note: Reviewer B considers the route-level fix sufficient since navigation-away is the high-stakes path; Reviewer A flags that the button-tap path (explicit user action) should also be logged. Both views have merit — at minimum, adding try/catch + logger.e (without user-facing error) to _cancelEditMode would be consistent with the route guard discipline.

Fix:

void _cancelEditMode() async {
  try {
    await ref.read(dashboardEditModeProvider.notifier).cancelEditMode();
  } catch (e, s) {
    logger.e('[Dashboard] cancelEditMode failed', error: e, stackTrace: s);
  }
}

W-N1 · [both reviewers] 🟡Med — Edit UI visible before controller.setEditMode(true) during async gap

lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:54–72

state = const DashboardEditState(isEditing: true);   // line 54 — UI renders edit mode HERE
                                                      // jiggle, trash zone, edit buttons show

await ref.read(uspLayoutPreferencesProvider.notifier).initialized;  // line 56 — can be slow

if (!state.isEditing) return;                         // line 60

final controller = ref.read(uspSliverDashboardControllerProvider);
// ... snapshot capture ...
controller.setEditMode(true);                         // line 72 — controller enters edit mode ONLY HERE

Trigger: Slow device or first-launch SharedPreferences initialization. uspLayoutPreferencesProvider.initialized awaits SharedPreferences.getInstance() + JSON deserialization — measurably non-zero on first launch.

Consequence: During the async gap (lines 54–72), isEditing == true causes the build to render the full edit-mode UI (jiggle animation, trash zone, action buttons) while DashboardController.setEditMode(true) has not yet been called. Drag and resize gestures are silently rejected by the controller. This is a visible "broken" window on slow devices. Additionally, if cancelEditMode() fires during this gap, _exitEditMode(revert: true) is called with both snapshots null (not yet captured), so the revert body is skipped entirely — and setEditMode(false) is called on a controller that was never put into edit mode.

Fix: Either defer the isEditing = true claim until after initialization completes, or add an isInitializing phase to suppress edit-mode UI until snapshots are captured:

// Option A: delay claim
await ref.read(uspLayoutPreferencesProvider.notifier).initialized;
if (state.isEditing) return;  // double-tap guard still works if re-ordered
state = const DashboardEditState(isEditing: true);
// ... capture snapshots, then controller.setEditMode(true)

W-N2 · [single — security] 🟡Med — Cancel-during-async-gap calls setEditMode(false) on non-edit-mode controller

lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:54–72

Related to W-N1: if cancelEditMode() fires after isEditing=true (line 54) but before controller.setEditMode(true) (line 72), _exitEditMode(revert: true) runs through finally and calls controller.setEditMode(false) on a controller that was never put into edit mode via setEditMode(true). Whether DashboardController.setEditMode(false) handles this gracefully (vs. assert/throw) depends on the package implementation. If the controller maintains internal state, this is an unpaired call.

This is partially mitigated by the if (!state.isEditing) return bail-out at line 60 (enterEditMode resumes and exits early), but _exitEditMode itself has no such guard (see W-3 / Suggestion below). The fix from W-N1 Option A would also resolve this.

💡 Suggestion Details

S-W5 · [both reviewers, CARRY-OVER] 🟢High — No idempotency tests for commitEditMode/cancelEditMode when isEditing == false

test/page/dashboard/providers/dashboard_edit_mode_provider_test.dart

New tests cover re-entrant enterEditMode and async-gap cancel. No test exercises commitEditMode() or cancelEditMode() called without a preceding enterEditMode. This is needed to validate the idempotency fix proposed in S-W3.

Suggested test:

test('commitEditMode/cancelEditMode are no-ops when not editing', () async {
  final container = await createContainer();
  await container.read(dashboardEditModeProvider.notifier).commitEditMode();
  expect(container.read(dashboardEditModeProvider).isEditing, isFalse);
  await container.read(dashboardEditModeProvider.notifier).cancelEditMode();
  expect(container.read(dashboardEditModeProvider).isEditing, isFalse);
});

S-W3 · [single — architecture] 🟢High — _exitEditMode has no isEditing guard

lib/page/dashboard/providers/dashboard_edit_mode_provider.dart (finally block)

_exitEditMode unconditionally emits state = const DashboardEditState() and calls controller.setEditMode(false) regardless of whether isEditing was true. Calling commitEditMode() or cancelEditMode() when already not editing (double-tap cancel, race with route-exit cancel) causes a spurious Riverpod rebuild on all ref.watch(dashboardEditModeProvider) subscribers.

Fix: Add at top of _exitEditMode:

if (!state.isEditing) return;

S-N3 · [single — architecture] 🟢High — _openLayoutSettings null-result design is implicit

lib/page/dashboard/views/usp_sliver_dashboard_view.dart:499–519

When the settings dialog is dismissed without a 'reset' or 'preset_changed' result (result == null), _openLayoutSettings returns without calling commitEditMode() or cancelEditMode(). The user remains in edit mode. This asymmetry (structural change → auto-exit; no-change dismiss → stay in edit) is intentional but undocumented.

Fix: Add a doc comment explaining the design intent:

/// On 'reset'/'preset_changed': immediately commits to preserve the just-applied
/// structural change. On any other result (null / dialog dismissed): stays in
/// edit mode to allow further fine-tuning before explicit commit or cancel.
Future<void> _openLayoutSettings() async { ... }

S-S1 · [single] 🟡Med — Type erasure cast in settings panel

lib/page/usp_layout_settings_panel.dart:127 (not in this diff, but now inconsistent with it)

final currentIds =
    currentLayout.map((e) => (e as Map)['id'] as String).toSet();

layoutSnapshot in DashboardEditState was tightened from List<dynamic>? to List<Map<String, dynamic>>? by this PR. However, currentLayout here comes from controller.exportLayout() directly (not from the snapshot), which returns List<dynamic>. The as Map + as String double-cast is unguarded — if id is absent or non-String, it throws a TypeError with no fallback.

Fix: Use typed access: (e as Map<String, dynamic>)['id'] as String? with null filtering, or add a null guard.


S-N5 · [single — architecture] ⚪Low — No widget test for _openLayoutSettings commit path

test/…/

The provider-level regression test (commitEditMode preserves layout changes applied during edit) covers the fix at provider level. A widget test for _openLayoutSettings verifying that 'reset'/'preset_changed' results transition isEditing to false while preserving the change would guard against re-introducing the PeterJhong bug at the view layer.

✅ What looks good (resolved this round)
  • W-4 (both reviewers) RESOLVEDisEditing = true is claimed synchronously before await initialized, and the post-await bail-out at line 60 (if (!state.isEditing) return) correctly handles the scenario where cancelEditMode() fires during the async gap. Re-entrant enterEditMode double-tap race is fully covered.
  • PeterJhong regression FIXED_openLayoutSettings now calls commitEditMode() (preserve) instead of the old exitEditMode(save: false) (revert) for 'reset' and 'preset_changed' results. The 'toggle_off' case was also removed — confirmed clean: usp_layout_settings_panel.dart no longer pops with 'toggle_off'. Regression test commitEditMode preserves layout changes applied during edit directly guards this path.
  • Type-tightening of layoutSnapshotList<dynamic>?List<Map<String, dynamic>>? is a correct and conservative tightening consistent with exportLayout()'s contract.
  • Route-level error handlingroute_usp_dashboard.dart now wraps cancelEditMode() in try/catch(e, s) with logger.e, matching the required discipline for the high-stakes navigation-away path.
  • linksys_route.dart deletionLinksysRoute class is cleanly consolidated into route_model.dart. No dangling imports.
  • New tests — Re-entrant guard test, async-gap cancel test, and commitEditMode-preserves-changes regression test all cover the key correctness properties added in this round.

Cross-reviewed by two independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.

@PeterJhongLinksys PeterJhongLinksys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified the edit-mode state extraction into dashboardEditModeProvider and the route onExit guard (cancel + revert to pre-edit snapshot on navigate-away). Independently confirmed the deletion of lib/route/linksys_route.dart is safe (zero imports; the active LinksysRoute lives in route_model.dart and supports the onExit param), and the referenced controller APIs exist. The remaining open edge-case warnings are pre-existing patterns carried over verbatim, not regressions introduced here. No blocking issues.

@AustinChangLinksys AustinChangLinksys merged commit 727ed87 into dev-2.6.0 Jul 13, 2026
2 checks passed
@AustinChangLinksys AustinChangLinksys deleted the fix/1037-dashboard-edit-mode-tab-switch branch July 13, 2026 03:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Dashboard] Edit mode still work if changing tab to others

2 participants